Balance over time ยท spending by category ยท N independent widgets sharing global filters ยท a teaching walkthrough. RADIO: Requirements โ Architecture โ Data model โ Interface โ Optimizations.
Restate the problem first: "We're building the overview page of a personal-finance app. A user with several linked accounts lands here and sees their financial life at a glance โ a net-worth header, a balance-over-time chart, spending broken down by category, their most recent transactions, and a card per account. A date-range picker and an account selector at the top control everything below them, and clicking into a chart drills down to the filtered transactions view."
The functional inventory is really a list of widgets โ net-worth header, balance chart, category chart, recent-transactions list, account cards โ plus two global controls that every widget obeys, plus drill-down navigation. Out of scope, and worth cutting aloud: a user-customizable widget builder, report exports, and any budgeting rules engine. Each of those is its own interview.
The non-functional requirements are where this question differs from the transactions table, and it's worth articulating the difference: the table is one big component with one data stream; the dashboard is many small components with many independent data streams. That shift changes what "good" means. First, perceived speed over total speed: the shell and skeletons should paint under a second, and widgets should settle independently as their data arrives โ the page assembles progressively rather than blocking on its slowest member. A reasonable overall target is all widgets settled under 2.5s at p75. Second, failure isolation: one widget erroring must degrade exactly one card, never the page. On a dashboard whose widgets depend on different backend aggregations, partial failure isn't an edge case โ it's a Tuesday. Third, honest freshness: these are aggregations over synced bank data, computed minutes ago, and the UI should stamp them as-of rather than performing a real-time-ness it doesn't have. Fourth, bundle discipline: charting libraries are heavy, and a dashboard that ships 300KB of chart code before first paint has lost the performance argument before rendering a single number.
The architecture is one repeated pattern, applied N times: the self-contained widget. Each widget owns its query hook, its skeleton, its error boundary, and its full set of states. Nothing about the balance chart's fetch, failure, or refresh is visible to the category chart. This is the structural enforcement of the failure-isolation requirement โ it's not that we handle a widget failure gracefully, it's that the architecture makes cross-widget failure impossible to express. When an interviewer asks "what if the category service is down?", the answer falls out of the structure: that card shows its error state with a retry button, and nothing else knows.
What binds the widgets is deliberately thin: the global filters, living in the URL. Each widget's query key includes them โ ['categorySums', range, accountIds] โ so when the user changes the date range, every widget's key changes, every query refetches in parallel, and any previously-viewed range serves instantly from cache while revalidating in the background. Two subtleties make this work well. First, because filters live in the URL rather than in a top-level useState, the page component isn't re-rendering the world on every change โ each widget subscribes to its own query result and re-renders only when its data changes. Second, this gives filter changes back-button behavior and deep-linkability for free, which matters for the drill-down story later.
The third piece is the query cache as the single mediator between widgets and network. All data flows: widget โ hook keyed on filters โ cache โ BFF. Real-time signals (a bank-sync webhook relayed over SSE) flow into the cache as invalidations, not into components. Keeping one mediator means deduplication, staleness, and refetching are solved once, centrally, instead of five times, divergently.
NetWorth { total, byType: {cash,
credit, invest}, asOf }
BalanceSeries {
granularity: 'day'|'week'|'month',
points: [{ t, balance }],
asOf }
CategorySums {
range, total,
slices: [{ category, amount, pct }],
asOf }
Account { id, name, mask, type,
balance, balanceAsOf,
status: 'ok'|'relink_required' }
The client renders aggregates; it never computes them. Bucketing is server-chosen so payloads stay constant-size. Every payload is stamped as-of. Money is integers. A broken bank link is a modeled state, not an error.
The defining decision of this data model is that aggregation happens on the server, and it's wrong twice over to do it on the client. The naive design ships raw transactions and lets the client sum them into a pie chart. The first problem is cost: a year of transactions is tens of thousands of rows and megabytes on the wire, to produce twelve numbers. The second problem is worse โ correctness. The client doesn't have all the data (it has a page), pending transactions carry non-final amounts, and holds, foreign exchange, and interest adjustments live server-side. A client-computed total will disagree with the server's, and in a finance app a wrong total is the worst possible bug because it's the number the user came to see. So the contract is: the server computes, the client renders. The transactions table owns raw rows; the dashboard owns projections. Drawing that line crisply also keeps the two views from fighting about which one is authoritative.
Bucketing is chosen server-side, by range. A seven-day range gets daily points; a one-year range gets weekly. The reason to put this on the server rather than downsampling client-side is that it caps the payload by construction โ roughly 360 points worst case, a few kilobytes โ regardless of how much underlying data exists. The client should never receive more resolution than it can draw. This also pins the bucketing logic (calendar boundaries, timezone handling, missing-day interpolation) in one place instead of reimplementing it per client platform.
Every payload carries as-of, and relink_required is part of the account model. The as-of stamp is the design being honest: these aggregates were computed at sync time, minutes ago, and the UI displays that fact instead of concealing it โ the rubric explicitly flags "assuming financial data is strongly consistent" as a red flag, and the as-of stamp is the visible artifact of not assuming it. The relink_required status models the routine reality of a Plaid-style product โ bank credentials expire โ as data, so the UI renders a purposeful "reconnect your account" card instead of a generic error, and stale numbers are never silently presented as current.
GET /accounts
GET /networth?range&accountIds
GET /balance-series?range&accountIds
GET /category-sums?range&accountIds
GET /transactions?limit=10&accountIds
โ alternative โ
GET /dashboard?range&accountIds
โ { networth, series, sums,
recent, accounts }
const { data } = useQuery(
['categorySums', range, accountIds],
{ staleTime: 60_000 })
// every widget provides:
// sized skeleton (no CLS)
// error card + retry
// empty state
// relink CTA (permission)
// "as of 2:14pm" stale badge
Per-widget endpoints versus one aggregate call is a genuine trade-off, and the interviewer wants to hear you argue both sides before picking. The case for per-widget endpoints: each widget gets independent caching (the accounts list can be five-minutes-fresh while balances are sixty-seconds-fresh), independent failure (a broken category aggregation returns a 500 to one widget, not a corrupted composite payload), and independent refetching (a balance webhook invalidates balances without refetching category sums). The case for the single /dashboard aggregate: one round trip instead of five, which matters when round-trip time dominates โ high-latency mobile networks โ and it gives you one consistency domain, meaning every number on screen was computed at the same instant. The costs mirror each other: the aggregate couples unrelated data's cache lifetimes and failure semantics; per-widget spends RTTs. The answer that scores: choose per-widget endpoints fired in parallel as the default, and say explicitly that the aggregate is the right call for a mobile client or wherever product demands the header and chart never disagree โ and note that a BFF can offer both by composing the same internal handlers, so this is a client-driven choice, not an architectural fork. That last sentence matters in Plaid's format, where the backend boilerplate is handed to you: it shows you can adapt to whichever shape they gave.
The widget contract is the reusable-component answer hiding inside this question. Every widget โ chart, list, header โ has the same lifecycle: loading, loaded, error, empty, permission-denied, stale. So the design factors that into a shared Widget wrapper that provides the five states and an error boundary, while each concrete widget supplies only its query key and its rendering of the happy path. This is worth presenting as deliberate component API design: the state matrix is the component's contract, and pushing it into a wrapper means no future widget can forget its error state โ the same "make the right thing structural" move as the error-boundary-per-widget decision. Blank space is never an acceptable render.
A request waterfall is what happens when requests that could run concurrently instead run sequentially, and the insidious thing is that nobody writes a waterfall on purpose โ the component tree writes it for you. Understanding the three ways it happens is the real content of this section.
Fetch-in-render is the classic. The page component fetches accounts; when accounts arrive it renders its children; each child then begins its own fetch. Every level of the tree adds a full round trip, because a child's fetch cannot start until its parent has rendered it. Four dependent levels at 300ms each and your dashboard takes 1.2 seconds to do what the network could have done in 300ms. The structure of your JSX has silently become the schedule of your network requests โ that's the sentence that shows you understand the failure, and the waterfall-versus-parallel timeline in the ยง2 diagram is the picture to draw while saying it.
Gated mounts are subtler. Code like {accounts && <Charts/>} makes the charts wait for the accounts response before they even mount โ even though the chart queries don't use the accounts data. The dependency is an accident of conditional rendering, not of data. The discipline is to ask, for every gate: does the child's query actually need the parent's data? Here, nothing does โ the chart endpoints take a range and account ids from the URL, not from the accounts response. The only true join (display names onto account cards) can happen client-side when both queries have landed, independently.
Lazy chunks that fetch on mount are the third variant: a code-split widget must download its JavaScript before its useQuery runs, so the network sits idle during the JS download, then starts the data fetch โ a two-step waterfall of code, then data.
The fix for all three is the same move: hoist fetch initiation to the route level, decoupled from component rendering. On navigation (or even on hover of the nav link โ intent-based prefetching), a route loader calls prefetchQuery for all five query keys, firing every request in parallel immediately. Components, whenever they happen to mount โ including after their lazy chunk arrives โ subscribe to queries already in flight rather than starting them. Rendering and fetching become independent concerns, connected only by the query key. Settle time collapses from the sum of latencies to the max of latencies, and the lazy-chunk problem dissolves because data was already loading while the code downloaded.
| Data | staleTime | Event trigger |
|---|---|---|
| Accounts list | 5 min | relink event |
| Net worth / balances | 60s | sync webhook, refocus |
| Series / category sums | 5 min | sync webhook, range change |
| Recent transactions | 30s | tx.created event |
Staleness tolerance is a property of the data, not of the app โ so the policy is a table, not a constant. Balances move and get 60s; the accounts list barely changes and gets 5 minutes. Being able to produce this table on request is what "thought about caching" looks like.
The strategy is stale-while-revalidate everywhere: on any request, show cached data immediately and refetch in the background, updating in place if anything changed. For a returning user this is transformative โ the dashboard paints instantly with slightly-old numbers (honestly stamped as-of) rather than blanking behind skeletons, and freshness arrives seconds later without interaction. SWR is the right default precisely because this page's data is already eventually consistent โ a background revalidation model matches the truth of the data instead of fighting it.
The deeper point to make: TTL is a guess, and event-driven invalidation replaces the guess with knowledge. Any fixed staleTime is a bet about when the backend's data changed. But this backend knows when it changed โ a bank sync completed, and a webhook fired. Relay that signal to the client over SSE and let it invalidate by tag: sync completes โ invalidate ['networth'], ['balanceSeries'], ['categorySums'] โ each refetches, in the background, only if currently mounted. The user sees fresh numbers seconds after the truth changed, and the client never polled for it. The TTLs stay in place as the floor โ push channels fail silently, so the design degrades from "fresh within seconds" to "fresh within staleTime plus a refocus refetch" rather than to "stale forever." Push is the optimization; TTL-plus-refocus is the guarantee.
Two mechanical details round this out, and both are the kind of thing interviewers probe for. Deduplication: several widgets key on ['accounts'] โ the cache layer collapses them into one request with multiple subscribers, which is precisely why the cache mediates all fetching rather than letting components fetch ad hoc. Race guarding: a user flips the range 7d โ 30d โ back to 7d quickly; the 30d response must not land into the 7d view. Query-key isolation handles most of it (each response lands in its own cache slot), and cancelling in-flight queries for abandoned keys handles the rest. Name the race explicitly โ unnamed races are the difference between a 3 and a 4 on the state-management pillar. One more sentence worth saying: there are no optimistic updates on this page, because there are no writes โ the dashboard is a read-only projection of money, and corrections arrive by invalidation. Knowing when optimistic UI is irrelevant (and when it would be forbidden โ money movement) is itself rubric material.
Charting libraries are among the heaviest dependencies a front end routinely takes: ECharts and Highcharts land around 300KB gzipped, Recharts around 100KB. Shipping that in the entry bundle means every user pays chart-parse-and-compile cost before the page becomes interactive โ including the majority of visits where the user glances at the header number and leaves. The move is to code-split the chart components so the shell โ header, account cards, recent transactions, all the actual numbers โ renders with zero chart code, and the chart chunks stream in behind sized skeleton placeholders. "Sized" is doing work in that sentence: a placeholder with the chart's exact final dimensions means zero cumulative layout shift when the real chart hydrates in. And because ยง5 hoisted data fetching to the route, the chart's data loads concurrently with its code โ the two-step waterfall never happens.
Two more decisions belong in this section. One library, org-wide. Two charting libraries in one bundle is paying the heaviest cost twice for no user-visible benefit, and it's the kind of thing that creeps in one team at a time โ flag it as a governance decision, not just a technical one. SVG rendering at this scale, canvas only under duress. The server's bucketing already caps series at ~360 points, and SVG handles that trivially while keeping chart elements in the DOM โ inspectable, styleable, and accessible. Canvas becomes necessary only in the many-thousands-of-points regime, and it costs you DOM-based accessibility when you take it.
Chart accessibility deserves its own paragraph, because almost every candidate skips it and the rubric explicitly doesn't. A chart is a visual encoding of data, and users who can't see the encoding still need the data. The floor: a visually-hidden data table or an aria-label summary carrying the actual content ("spending by category: groceries 32%, rent 28%โฆ"), keyboard-reachable data points or an accessible tooltip equivalent, and never encoding meaning in color alone โ the pending-versus-posted distinction, for instance, needs a shape or label, not just a lighter shade. Mentioning color-independence in a finance app also quietly covers the red/green colorblindness problem that every gains/losses UI has.
Security and privacy. The token story matches the transactions table โ short-lived, scoped tokens in HttpOnly SameSite cookies via the BFF, nothing sensitive in localStorage or the bundle โ because one XSS reading a bank token is the catastrophe the whole model exists to prevent. The dashboard adds one UI-level feature worth proposing unprompted: privacy mode, a single toggle that masks every dollar amount on screen. Users open finance dashboards in screen-shares, on trains, next to coworkers; it's a cheap feature that signals you think about the person holding the screen, not just the code. Keep the toggle's state in memory, not in any persisted or logged store. Telemetry follows the same scrubbing discipline: widget timings, error codes, event names โ never amounts, categories, or merchant names. And the relink_required state doubles as a security posture: expired-credential accounts show a reconnect call-to-action rather than stale balances dressed up as current โ least-privilege presentation of data the client no longer has the right to refresh.
Resilience. The per-widget error boundary is the structural centerpiece โ a crash in chart-rendering code degrades one card to a retry panel while the rest of the page lives โ but walk the rest of the matrix too: sized skeletons while loading; per-widget retry with exponential backoff and jitter; empty states that say something useful ("no spending in this range"); the relink CTA for permission-denied; and the as-of badge plus a reconnect banner when the event channel drops. Offline or on a flaky connection, the SWR cache means the dashboard still renders โ stale, stamped, and honest โ instead of white-screening. For a finance app on mobile networks, "degrades to stale-but-labeled" versus "degrades to blank" is the difference that matters.
Observability. Instrument per-widget: time-to-data for each widget, overall settle time (time until the last widget resolved), error rate by endpoint, and event-channel disconnect rate. Correlation IDs ride every request so a support report traces end-to-end. Alerts key on the two numbers that mean users are hurting: dashboard settle-time p95 regressing, and any financial widget's error rate spiking. The rubric's red flag is having no telemetry on financial failure modes; the strong answer names which metrics page a human.
Why not one GraphQL query for the whole dashboard?
It's a legitimate alternative and deserves a fair hearing: one round trip, client-declared shape, no over-fetching. The costs: you reintroduce single-payload failure and caching semantics unless you split at the cache layer anyway, and you take on schema and server infrastructure that this interview's boilerplate may not include. The framing that scores: per-widget REST in parallel is the simpler default; GraphQL or a BFF aggregate earns its keep when round trips dominate (mobile) or when clients genuinely need different shapes (web vs mobile vs partner embeds). Name the condition under which you'd switch โ that's what makes it a trade-off rather than a preference.
A filter change refetches five queries โ isn't that wasteful?
It's five small aggregate queries in parallel โ a few KB total โ not five table scans from the client's perspective; the server was computing these aggregates anyway. And the cache means previously-viewed ranges cost nothing: flipping between 7d and 30d after the first visit to each is two cache hits with background revalidation. The alternative โ trying to derive the 7d view client-side from the 30d data โ reintroduces client-side aggregation of financial data, which ยง3 ruled out on correctness grounds. Cheap, correct, and cached beats clever.
The net-worth header and the balance chart disagree. Bug?
Expected behavior, honestly displayed. They're independent queries with independent as-of stamps; a bank sync can land between their fetches, so one reflects it and the other doesn't for a few seconds. The design's answer is the visible as-of stamp per widget, plus event-driven invalidation shrinking the disagreement window to seconds. If product requires that they never disagree, that's the argument for fetching them as one payload โ one consistency domain โ and it's a product decision with a real cost (coupled caching and failure), not a free fix. Being able to say "this is a consistency-domain choice" is the senior answer.
How stale can the dashboard get if the event channel dies silently?
Bounded, and you can compute the bound: staleTime per data class (60s balances, 5min aggregates) plus refetch-on-window-focus as a backstop whenever the user returns to the tab. The as-of badge keeps the staleness visible, and a reconnect banner appears when the SSE connection is known-dead. The design principle: push improves freshness, but TTL-plus-refocus guarantees it. Never build a client whose correctness depends on a webhook arriving.
Walk through the drill-down: clicking the "groceries" slice.
Navigate to /transactions?category=groceries&range=30d&accounts=โฆ โ serializing the drill-down into the transaction table's URL contract. The table page owns everything from there with its own keyed queries. This is the payoff of both pages keeping filter state in the URL: drill-down is just navigation, it's deep-linkable and back-button-correct with zero shared in-memory state between the two pages. Prefetch the first page of results on slice hover and the transition feels instant.
What changes on mobile?
Single-column stack with the header and one chart above the fold; below-fold widgets lazy-mount on approach via IntersectionObserver so first paint only fetches what's visible. This is also where the aggregate endpoint argument flips โ on high-RTT networks, one round trip for above-fold data beats five, so the mobile client may genuinely prefer GET /dashboard. Charts simplify: fewer axis ticks, tap targets instead of hover tooltips (hover doesn't exist), and consider replacing the pie with a ranked bar list, which reads better at 375px anyway.
Skeletons, spinners, or progressive rendering?
Sized skeletons per widget, and each widget swaps to real content the moment its own data lands โ progressive assembly. One global spinner is the worst option: it converts max(latencies) into perceived sum(latencies) by blocking everything on the slowest widget, and it throws away the parallelism ยง5 bought. When cache exists, skip skeletons entirely โ show stale data with a subtle revalidation shimmer, because slightly-old numbers now beat placeholders every time.
Shell + skeletons under 1s; all widgets settled under 2.5s p75. With parallel fetching, settle โ max(endpoint latencies) โ 300โ500ms after shell. "Five 300ms endpoints in parallel is 300ms, not 1.5 seconds" โ the arithmetic that justifies the entire ยง5 section in one sentence.
Series โค360 points โ 10KB; category sums ~1KB; the whole dashboard's data under 50KB. Chart library ~100KB gz, code-split so the shell's time-to-interactive never pays it. One SSE connection buys freshness-in-seconds across every widget โ cheaper than any polling schedule that achieves the same.
| Pillar | Where it's covered |
|---|---|
| 1 Product framing | ยง1 โ widget independence, progressive settle, honesty about freshness, non-goals |
| 2 Data/API contract | ยง3โ4 โ server aggregation argued on cost + correctness, bucketing, as-of, per-widget vs aggregate both-sides |
| 3 Rendering | ยง2, ยง7 โ widget decomposition, code-split charts, CLS-safe skeletons, chart a11y in full |
| 4 State/caching/sync | ยง4โ6 โ URL filters, keyed SWR, event-driven tag invalidation, dedupe, named race guards |
| 5 Security/privacy | ยง8 โ token model, privacy mode, telemetry scrubbing, relink as least-privilege |
| 6 Reliability/correctness | ยง4, ยง8, ยง9 โ five-state widget contract, error boundaries, partial failure, offline-stale-honest |
| 7 Perf/observability | ยง5, ยง7, ยง10 โ waterfall analysis, bundle discipline, quantified budgets, per-widget RUM + paging alerts |